# 数据库设计
30 秒速记
- 从业务规则和查询方式出发设计表,不是把前端对象字段一股脑平铺
- 必须始终成立的规则交给数据库约束:主键、
NOT NULL、UNIQUE、外键 - 「先查有没有再插入」在并发下会漏,唯一约束才是最后一道防线
- 索引按实际查询建,用
EXPLAIN验证,每多一个索引写入就慢一点 - 时间用
DATETIME、金额用DECIMAL或整数分、状态用明确的枚举值,别全用varchar
设计表的时候先问两个问题:哪些规则任何时候都不能被打破,以及页面会怎么查这些数据。 第一个问题决定约束,比如邮箱不能重复,就加唯一索引,别只靠代码里先查一下,两个请求同时进来都能查到「没有」,然后各插一条。第二个问题决定索引,列表页按状态筛选、按时间倒序,就建对应的联合索引,再用 EXPLAIN 看有没有真的用上。字段类型也要讲究,时间存字符串、金额存浮点,后面排序和对账都会出问题。
理解「数据库设计」时,要先明确输入、运行阶段和可观察输出。这里的交互实验会分别执行正常路径与失败路径,并把 Node.js 的关键状态变化和同屏代码对应起来;不能只记命令名称。
工程中还要验证退出码、资源清理和异常输入。属于 database 场景的配置应保持最小可复现,并在开发环境与 CI 中使用相同命令复跑,避免把平台差异或隐式默认值带到线上。
用一张用户表对比一下「平铺字段」和「按业务规则设计」的差别:
-- 只是把对象字段平铺
CREATE TABLE user_info (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255),
password VARCHAR(255),
create_time VARCHAR(20),
level INT
);
-- 按业务规则设计
CREATE TABLE user_info (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(100) NOT NULL, -- 存 bcrypt 摘要,不存明文
status ENUM('active', 'disabled') NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL, -- 软删除
UNIQUE KEY uk_email (email), -- 邮箱唯一由数据库保证
KEY idx_status_created (status, created_at) -- 服务后台列表页查询
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
每一处改动背后都是一个具体问题:UNIQUE 解决并发注册重复;NOT NULL 防止脏数据;DATETIME 让排序和范围查询正确;utf8mb4 能存 emoji;联合索引对应 WHERE status = ? ORDER BY created_at DESC。
建完索引用 EXPLAIN 验证:
EXPLAIN SELECT * FROM user_info WHERE status = 'active' ORDER BY created_at DESC LIMIT 20;
-- key: idx_status_created,Extra 里没有 Using filesort,说明索引起作用了
软删除要留意一点:加了 deleted_at 之后,email 的唯一约束会让「注销后用同一邮箱重新注册」失败,这时要么把唯一键改成包含删除标记的组合,要么注销时改写邮箱。
面试官追问
追问 1注册时代码里先查了邮箱没被占用,为什么还会出现重复账号?
两个请求几乎同时进来,都查到「没有」,然后各插一条。给 email 加 UNIQUE 约束,第二条插入会报 ER_DUP_ENTRY,代码捕获后提示「邮箱已注册」。
追问 2订单列表按 status 筛选、created_at 倒序,索引怎么建?
建联合索引 (status, created_at),等值条件的列放前面。EXPLAIN 看 key 列用上了这个索引,Extra 里没有 Using filesort 就对了。
追问 3状态要加一个 pending,表上有 CHECK 约束,先改代码还是先改库?
先改库放宽约束,再发会写 pending 的代码。反过来,新代码一上线写入就被约束拒绝。注意 MySQL 到 8.0.16 才真正执行 CHECK,之前只解析不生效。
追问 4索引越多查询越快,能不能把常用字段都加上索引?
不行。每个索引都要在插入、更新时同步维护,写多的表加一堆索引写入会明显变慢,还占空间。按真实的慢查询来加,用不上的索引定期清掉。
追问 5创建时间存成 varchar(20),有什么问题?
格式一不统一排序就乱了,'2024-9-1' 会排到 '2024-10-1' 后面;按时间范围查也用不好索引。直接用 DATETIME,默认值写 CURRENT_TIMESTAMP。
# 数据库设计
# 初始化数据库脚本
30 秒速记
- 初始化脚本 = 建表 + 建约束和索引 + 插默认数据,一条命令跑完
- 任何一条
SQL失败都要以非零退出码结束,别打印「完成」然后退出码0 - 能重复执行:
CREATE TABLE IF NOT EXISTS,默认数据用INSERT IGNORE或ON DUPLICATE KEY UPDATE - 成功和失败分支都要关连接池,不然脚本跑完进程挂着不退
- 表结构上线后还要改,就别再改初始化脚本,用迁移工具一版一版加
初始化数据库脚本要做到三点:一条命令建好表和默认数据,出错时明确失败,重复执行不会搞坏数据。 我见得最多的问题是脚本 catch 了错误打个日志就结束,退出码还是 0,CI 以为成功了,结果库只建了一半。另一个是连接池没关,脚本跑完进程一直挂着。表结构一旦上线,后续改动我会用迁移工具管理,每次变更一个文件,有版本号能回溯,而不是反复改那个初始化脚本。
理解「初始化数据库脚本」时,要先明确输入、运行阶段和可观察输出。这里的交互实验会分别执行正常路径与失败路径,并把 Node.js 的关键状态变化和同屏代码对应起来;不能只记命令名称。
工程中还要验证退出码、资源清理和异常输入。属于 database 场景的配置应保持最小可复现,并在开发环境与 CI 中使用相同命令复跑,避免把平台差异或隐式默认值带到线上。
一个靠谱的 Node 初始化脚本骨架:
const fs = require('fs')
const path = require('path')
const mysql = require('mysql2/promise')
async function main() {
const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
multipleStatements: true, // 允许一次执行多条 SQL
})
try {
const dir = path.join(__dirname, 'sql')
const files = fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()
for (const file of files) {
await pool.query(fs.readFileSync(path.join(dir, file), 'utf8'))
console.log(`✔ ${file}`)
}
const [rows] = await pool.query('SHOW TABLES')
console.log('当前表:', rows.map(r => Object.values(r)[0])) // 例如 [ 'user_info' ]
} finally {
await pool.end() // 成功失败都关,进程才能正常退出
}
}
main().catch(err => {
console.error('初始化失败:', err.message)
process.exitCode = 1 // CI 靠这个判断失败
})
几个要点:
- 文件按名字排序执行,比如
001-user.sql、002-seed.sql,顺序可控 - 配置从环境变量读,密码不进仓库
- 默认数据写成
INSERT IGNORE INTO ...,前提是表上有对应的唯一键,否则IGNORE起不到去重作用 - 表上线之后的结构变更交给
Knex、Prisma Migrate这类迁移工具,每次变更一个带版本号的文件
面试官追问
